// gaz.treeview.js - a jQuery-based library to manage trees
//
//   Copyright © 2007 Studio Associato Di Nunzio e Di Gregorio
//
//   Authors:
//      Federico Di Gregorio <fog@initd.org>
//      Pierluidi Di Nunzio  <gg@initd.org>
//
// This program is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public Licenseas published by
// the Free Software Foundation, either version 3 of the License, or (at 
// your option) any later version. See the file Documentation/LGPL3 in the
// original distribution for details. There is ABSOLUTELY NO warranty.

;(function($) {
    var Gaz = window.Gaz || (window.Gaz = {});

    /* Some private utility functions and some private data */
    
    function makeEventHandler(self, func) {
        return function(evt) {
            return func.apply(self, arguments);
        };
    }    
    
    /* jQuery extensions */
    
    $.fn.gazTreeView = function(store, options) {
        if (typeof store == 'undefined' && this.length >= 1)
            return this.get(0).gazTreeView;
        else
            return this.each(function() {
                this.gazTreeView = Gaz.TreeView(this, store, options);
            });
    };
    
    /* gaz treeNode function/class */
    
    $.extend(Gaz, {
        TreeView: (function() {
            var serial = 0;
            return function(elt, store, options) {
                var id = options.id || "treeview-"+serial++;
                return new Gaz.TreeView.instance(elt, id, store, options);
            };
        })()
    }); 
    
    
    Gaz.TreeView.instance = function(elt, id, store, options) {
        this.id = id;
        this.target = $(elt);
        this.eventHandlers = {click: []};

        this.options = $.extend({
            columns:                null,
            dragOffset:             5,
            dragContainer:          "body",
            dragFilter:             null,
            dropHandler:            null,
            
            sortHeaders:            null,
            filterHeaders:          null,

            sortFunction:           Gaz.TreeView.sortOnId,
            filterFunction:         Gaz.TreeView.filterExpander,
            backFunction:           null,

            createId:               Gaz.TreeView.createId,
            createRow:              Gaz.TreeView.createRow,
            createDragRow:          Gaz.TreeView.createDragRow,
            applySortHandle:        Gaz.TreeView.applySortHandle,
            
            cssTreeRowClass:        "gaz-tree-row",
            cssTreeRowLastClass:    "gaz-tree-row-last",
            cssTreeExpanderClass:   "gaz-tree-expander",
            cssTreeLevelClass:      "gaz-tree-level-",
            cssSortHandleClass:     "gaz-sort-handle"
        }, options);
        
        // Some functions should be called as methods or could be changed
        // after the instantiation of the tree view to modify its behaviour
        // so are copied from the options to the instance itself.
        
        this.createId         = this.options.createId;
        this.createRow        = this.options.createRow;
        this.createDragRow    = this.options.createDragRow;
        this.sortFunction     = this.options.sortFunction;
        this.filterFunction   = this.options.filterFunction;
        this.backFunction     = this.options.backFunction;
        this.applySortHandle  = this.options.applySortHandle;

        this.target.bind('click.'+this.id, null, makeEventHandler(this, this.onClick));
        if (this.options.dragOffset && this.options.dragOffset > 0) {
            this.target.bind('mousedown.'+this.id, null, makeEventHandler(this, this.onMouseDown));
        }
        
        if (this.options.sortHeaders)
            this.setupSortHeaders(this.options.sortHeaders);
        
        this.storeAttach(store);

        return this;
    };
        
    $.extend(Gaz.TreeView.instance.prototype,
    {
        storeAttach: function(store) {
            if (this.store)
                this.storeDedatch();
                
            this.store = store;
            this.store.createClientData(this.id, this);            
            $(this.store).bind('tree-changed.'+this.id, makeEventHandler(this, this.onTreeChanged));
            
            // Draw the first set of rows.
            this.replaceRows(null);
        },

        storeDetach: function() {
            if (this.store) {
                $(this.store).unbind('tree-changed.'+this.id);
                this.store.removeClientData(this.id, this);
                this.store = null;  
            }
            this.clear();
        },
        
        setupSortHeaders: function(opts) {
            // First search for a generic pattern and create default sort functions
            // for all matched elements, then apply custom sort functions for specific
            // headers.
            
            var sortEventHandler = makeEventHandler(this, this.onSortHeadersClicked);
            var applySortHandle  = makeEventHandler(this, this.applySortHandle);
            
            $(opts.headers).each(function() {
                var c = $(this).attr("class");
                var i = c.indexOf(opts.pattern);
                if (i >= 0) {
                    var e = c.indexOf(' ', i);
                    var column = c.substring(i+opts.pattern.length, e != -1 ? e : c.length);
                    var handle = applySortHandle(null, this, column, "none");
                    $(handle).addClass("sort-none");
                    handle.gazSortColumn = column;
                    $(this).bind('click', handle, sortEventHandler);                 
                }
            });
        },
        
        clear: function() {
            // Unfortunately jQuery's this.target.html("") can take ages!
            this.target[0].innerHTML = "";
        },
        
        redraw: function() {
            this.clear();
            this.replaceRows(null);
        },

        removeRows: function(row, elt) {
            if (!row) {
                this.clear();
                return null;
            }
            if (!elt && row)
                elt = this.target.find("#"+this.createId(row));
            
            var level = row.level;            
            var succ = null;
            
            while (elt && elt.length == 1) {
                var succ = elt.next();
                var domelt = elt[0];
                if (domelt.gazRow) {
                    domelt.parentNode.removeChild(domelt);
                }
                // Stop at the next node with the same level.
                elt  = (succ.length == 1 && succ[0].gazRow.level > level) ? succ : null;
            }
            
            return succ;
        },
        
        replaceRows: function(row) {
            var self = this;
            var rows = this.store.getRows(this.sortFunction, this.filterFunction, this.backFunction, this, row ? row.id : null);
            
            // If the row is the root of the tree we set it to null and replace
            // whole tree. This is needed to avoid fake duplicates when the root
            // is not shown (all other cases where a node is not shown should be
            // managed by the filters and the createRow hook.)
            
            if (row && row.isRoot()) row = null;
            
            var elt = row ? $("#"+this.createId(row)) : null;
            var target = this.target[0];
            
            // Remove target from DOM, fill it fully and then add-it back to get
            // a little speed-up from the browser not redrawing every single row.
            //
            // FIXME: apparently NOT removing the element from the DOM is faster
            // when using firefox. Deactivated untill better data is available.
            //
            //var parent = this.target.parent().get(0);
            //var point = this.target.next().get(0) || null;
            //parent.removeChild(target);
            
            // If the given row and its descendent nodes are displayed just remove
            // them from the DOM, they will be replaced by the new version (if row
            // is null we just remove the whole tree, useful for complete redraw.)
            
            var ins = this.removeRows(row, elt) || null;
            if (ins) ins = ins[0] || null;
            
            // If the row is not null (or the root) but we didn't found it in the
            // tree we don't display it and its childern. We're called "replaceRows"
            // after all!

            function isLast(row, i) {
                while (i < rows.length) {
                    if (rows[i].level == row.level)
                        return false;
                    else if (rows[i].level < row.level)
                        return true;
                    i += 1;
                }
                return (ins && ins.gazRow.level < row.level);
            }
            
            var elements = new Array(rows.length);
            
            if (!row || elt) {
                for (var i=0 ; i < rows.length ; i++) {
                    var last = isLast(rows[i], i+1) || (i == rows.length-1 && ins == null);
                    var row = elements[i] = this.createRow(rows[i], {last: last})[0];
                    row.gazRow = rows[i];
                    target.insertBefore(row, ins);
                }
            }
            
            // FIXME: see comment above.
            //parent.insertBefore(target, point);
            
            this.triggerAfterRedraw(elements);
        },
        
        rowExpand: function(row) {
            this.rowSet(row, 'expanded', true);
            this.replaceRows(row);
        },

        rowCollapse: function(row) {
            this.rowSet(row, 'expanded', false);
            this.replaceRows(row);
        },

        rowExpandAll: function(row) {
            this.store.each(
                function(node, self) {
                    if (node.children.length > 0)
                        self.rowSet(node, 'expanded', true);
                },
                this, row);
            this.replaceRows(row);
        },

        rowCollapseAll: function(row) {
            this.store.each(
                function(node, self) {
                    if (node.children.length > 0)
                        self.rowSet(node, 'expanded', false);
                },
                this, row);
            this.replaceRows(row);
        },
        
        /* EVENTS AND HANDLERS */
        
        bind: function(event, filter, data, handler) {
            var handlers = this.eventHandlers[event] || null;
            
            // If we don't have a special set of handlers that requires delegation
            // with filtering we simply ignore the filter and propagate the event
            // using the standard jQuery's .bind().
            
            if (handlers == null) {
                $(this).bind(event, data, handler);
            }
            
            // Else save the handler in the group of delegates.
            
            else {
                var h = {
                    handler: (typeof data == 'function' ? data : handler),
                    data:    (typeof data == 'function' ? null : data)
                }
                if (typeof filter == 'function')
                    h.filter = filter;
                else
                    h.query = filter;

                handlers.push(h);
                this.eventHandlers[event] = handlers;
            }
            
            return this;
        },
        
        trigger: function(event, evt) {
            var elt = $(evt.target);
            var ehs = this.eventHandlers[event] || [];
            for (var i=0, l=ehs.length ; i < l ; i++) {
                var eh = ehs[i];
                if ((eh.filter && eh.filter(evt.target)) || (eh.query && elt.is(eh.query)) || (!eh.filter && !eh.query)) {
                    evt.data = eh.data;
                    if (eh.handler(evt, this, this.rowElements(evt.target)))
                        break;
                }
            }
        },

        triggerAfterRedraw: function(rows) {
            $(this).trigger('after-redraw', [this, rows]);
        },

        onTreeChanged: function(evt, rows) {
            for (var i=0 ; i < rows.length ; i++) {
                this.replaceRows(rows[i]);
            }
        },
                
        onClick: function(evt) {
            // This is the generic dispatcher function for clicks that should behave
            // mostly the same over a lot of elements like, for example, the tree
            // expanders. If the element that generated the click dosen't have the
            // right class this event handler does nothing.
            var elt = $(evt.target);
            if (elt.hasClass('gaz-tree-expander') || elt.parent().hasClass('gaz-tree-expander'))
                this.onExpanderClicked(evt);
                
            // Here we check for delegated event handlers.
            this.trigger('click', evt);
        },

        onMouseDown: function(evt) {
            // This gets called only if drag and drop was enabled. If we have no filter
            // or we have a filer and the element under the mouse passes the test, we
            // save the mouse position and setup event handling for the drag operation.
            
            if (this.options.dragFilter && !$(evt.target).is(this.options.dragFilter))
                return;
            
            this._dragRow = this.row(evt.target);
            this._dragX = evt.clientX;
            this._dragY = evt.clientY;
            this._dragStarted = false;
            this._dragMoveHandler = makeEventHandler(this, this.onMouseMove);
            this.target.liteBind('mousemove', this._dragMoveHandler);
            $(document).bind('mouseup.'+this.id, makeEventHandler(this, this.onMouseUp));
        },

        onMouseUp: function(evt) {
            $(document).unbind('mouseup.'+this.id);
            this.target.liteUnbind('mousemove', this._dragMoveHandler);
            if (this._dragGhost)
                this._dragGhost.remove();
            this._dragMoveHandler = null;
            this._dragX = this._dragY = -1;
            
            var dragged = this._dragRow;
            var newParent = this.row(evt.target);
            
            if (this._dragStarted && newParent && this.store.moveNodeCheck(dragged, newParent)) {
                if (!this.options.dropHandler || this.options.dropHandler(evt, this, dragged, newParent) !== false)
                    this.store.moveNode(dragged, newParent);

                this._dragRow = null;
                this._dragStarted = false;
            }
        },

        onMouseMove: function(evt) {
            var ghost;
            if (!this._dragStarted &&
                (Math.abs(evt.clientX - this._dragX) >= this.options.dragOffset
                 || Math.abs(evt.clientY - this._dragY) >= this.options.dragOffset)) {
                var offset = this.rowElements(this._dragRow).offset();
                if (this.options.dragFilter) {
                    offset.top  = evt.clientY + 2;
                    offset.left = evt.clientX + 2;
                }
                
                this._dragX = offset.left;
                this._dragY = offset.top;
                this._dragMouseX = evt.clientX;
                this._dragMouseY = evt.clientY;
                this._dragStarted = true;
                this._dragGhost = this.createDragRow(this._dragRow);
                ghost = this._dragGhost[0];
                ghost.style.position = 'absolute';
                ghost.style.top  = this._dragY;
                ghost.style.left = this._dragX;
                $(this.options.dragContainer).append(ghost);
            }
            if (this._dragStarted) {
                ghost = this._dragGhost[0];
                ghost.style.top  = (this._dragY + evt.clientY - this._dragMouseY) + "px";
                ghost.style.left = (this._dragX + evt.clientX - this._dragMouseX) + "px";
            }
        },

        /* onMouseDown: function(evt) {
            // This gets called only if drag and drop was enabled. It just save the current
            // mouse position and setup event handling for the drag operation.
            this._dragRow = this.row(evt.target);
            this._dragX = evt.clientX;
            this._dragY = evt.clientY;
            this._dragStarted = false;
            this._dragMoveHandler = makeEventHandler(this, this.onMouseMove);
            this._dragUpHandler = makeEventHandler(this, this.onMouseUp);
            this.target.liteBind('mousemove', this._dragMoveHandler);
            $(document).bind('mouseup.'+this.id, this._dragUpHandler);
        },

        onMouseUp: function(evt) {
            $(document).unbind('mouseup.'+this.id);
            this.target.liteUnbind('mousemove', this._dragMoveHandler);
            if (this._dragGhost)
                this._dragGhost.remove();
            this._dragUpHandler = this._dragMoveHandler = null;
            this._dragX = this._dragY = -1;
            
            var dragged = this._dragRow;
            var newParent = this.row(evt.target);
            console.log(this.row(evt.target));
            
            if (this._dragStarted && newParent && this.store.moveNodeCheck(dragged, newParent)) {
                if (!this.dropHandler || this.dropHandler(dragged, newParent) != true)
                    this.store.moveNode(dragged, newParent);

                this._dragRow = null;
                this._dragStarted = false;
            }
        },

        onMouseMove: function(evt) {
            var ghost;
            if (!this._dragStarted 
                 && (!this.options.dragCondition || $(evt.target).is(this.options.dragCondition))
                 && (Math.abs(evt.clientX - this._dragX) >= this.options.dragOffset
                     || Math.abs(evt.clientY - this._dragY) >= this.options.dragOffset)) {
                var offset = this.options.dragCondition ?
                    $(evt.target).offset() : this.rowElements(this._dragRow).offset();
                this._dragX = offset.left;
                this._dragY = offset.top;
                this._dragMouseX = evt.clientX;
                this._dragMouseY = evt.clientY;
                this._dragStarted = true;
                this._dragGhost = this.createDragRow(this._dragRow);
                ghost = this._dragGhost[0];
                ghost.style.position = 'absolute';
                ghost.style.top  = this._dragY;
                ghost.style.left = this._dragX;
                $(this.options.dragContainer).append(ghost);
            }
            if (this._dragStarted) {
                ghost = this._dragGhost[0];
                ghost.style.top  = (this._dragY + evt.clientY - this._dragMouseY) + "px";
                ghost.style.left = (this._dragX + evt.clientX - this._dragMouseX) + "px";
            }
        }, */

        onSortHeadersClicked: function(evt) {
            var elt  = $(evt.data);
            
            var sortType;
            if (elt.hasClass("sort-none")) {
                elt.removeClass("sort-none");
                sortType = "asc";
            }
            else if (elt.hasClass("sort-asc")) {
                elt.removeClass("sort-asc");
                sortType = "desc";
            }
            else if (elt.hasClass("sort-desc")) {
                elt.removeClass("sort-desc");
                sortType = "none";
            }

            // Apply the new sort type to the handle, to make sure client code has the
            // opportunity to do any changes (like replacing an img src attribute.) Also,
            // remove the sorting from the old header if present.
            
            this.applySortHandle(evt.data, null, evt.data.gazSortColumn, sortType);
            elt.addClass("sort-"+sortType);
            if (this.lastSortHandle && this.lastSortHandle != evt.data) {
                this.applySortHandle(this.lastSortHandle, null, this.lastSortHandle.gazSortColumn, "none");
                this.lastSortHandle = null;
            }
            
            if (sortType != "none") {
                if (this.store)
                    this.sortFunction = this.store.createSortFunction(evt.data.gazSortColumn, sortType);
                this.lastSortHandle = evt.data;    
            }
            else {
                this.sortFunction = this.options.sortFunction;
            }
            
            this.redraw();
        },
                
        onExpanderClicked: function(evt) {
            // The default is to just expand (or unexpand) the current row.
            var row = this.row(evt.target);
            if (row) {
                if (this.rowGet(row, 'expanded'))
                    this.rowCollapse(row);
                else
                    this.rowExpand(row);
            }
        },

        /* UTILITY FUNCTIONS TO ACCESS ROWS */
        
        row: function(elt) {
            if (typeof elt.parent != "undefined")
                return elt;
            
            elt = $(elt);
            while (elt.length > 0) {
                if (elt[0].gazRow)
                    return elt[0].gazRow;
                elt = elt.parent();
            }
            return null;
        },
        
        rowElements: function(obj) {
            var self = this;
            return $(obj).map(function() {
                var obj = this;
                if (obj.nodeType) {
                    obj = $(obj);
                    while (obj.length > 0) {
                        if (obj[0].gazRow)
                            return obj[0];
                        obj = obj.parent();
                    }
                }
                else if (typeof obj.parent != "undefined") {
                    obj = $("#"+self.createId(obj));
                    if (obj.length > 0 && obj[0].gazRow)
                        return obj[0];
                }
            });
        }
    });
    
    /* DEFAULT UTILITY FUNCTIONS: FILTERS, SORT FUNCTIONS, ROW CONSTRUCTORS */
    
    $.extend(Gaz.TreeView,
    {
        // Function: filterExpander
        // Filter nodes depending on their opened/closed status.
        
        filterExpander: function(node, treeview) {
            if (node.isRoot())
                return Gaz.TreeStore.FILTER_EXCLUDE_NODE;
            else if (treeview.rowGet(node, 'expanded'))
                return Gaz.TreeStore.FILTER_ALL;
            else if (node.parent.isRoot() || treeview.rowGet(node.parent, 'expanded'))
                return Gaz.TreeStore.FILTER_EXCLUDE_SUBTREE;
            else
                return Gaz.TreeStore.FILTER_NONE;
        },

        // Function: sortOnId
        // Default sort function that sorts on node id.
        
        sortOnId: function(a, b, treeview) {
            if (typeof a.id == 'number')
                return a.id - b.id;
            else
                return a.id.toString().localeCompare(b.id.toString());
        },
        
        createId: function(row) {
            // Note that we MUST generate unique id values (because they will be
            // used as real XML IDs, so we prepend our unique id to the row's one.
            return this.id+"-row-"+row.id;
        },
        
        createRow: function(row, features) {
            var opts = this.options;
            var expander = "gaz-tree-expander-empty";
            if (row.children.length) {
                if (this.rowGet(row, 'expanded'))
                    expander = "gaz-tree-expander-opened";
                else
                    expander = "gaz-tree-expander-closed";
            }
            var rclass = opts.cssTreeRowClass + " " + (features.last ? opts.cssTreeRowLastClass + " " : "");
            
            var tr = '<tr id="'+this.createId(row)
                   + '" class="'+rclass+' '+opts.cssTreeLevelClass+row.level+'">';
            var td = '<td class="'+opts.cssTreeExpanderClass+'">'
                   + '<span class="'+expander+'">'+row.id+'</span></td>';
            
            function append(v) { td += '<td>'+v+'</td>'; }
            
            if (row.data && opts.columns) {
                for (var col in opts.columns) {
                    var val = row.data[opts.columns[col]];
                    if (typeof val != 'undefined')
                        append(val);
                    else
                        append("");
                }
            }

            return $(tr+td+"</tr>");
        },

        createDragRow: function(row) {
            var elt = this.rowElements(row);
            return elt.clone().css({background: "lightgrey", border: "1px solid black", width: elt.width()+"px"});
        },
        
        applySortHandle: function(handle, header, column, sortType) {
            if (!handle)
                return $('<span class="'+this.options.cssSortHandleClass+'"></span>').appendTo(header)[0];
            else
                return handle;
        }
    });

})(jQuery);
